/**
 * @license
 * Copyright (C) 2018 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
import '../../shared/gr-comment-thread/gr-comment-thread.js';
import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
import '../gr-diff/gr-diff.js';
import '../gr-syntax-layer/gr-syntax-layer.js';
import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
import {PolymerElement} from '@polymer/polymer/polymer-element.js';
import {htmlTemplate} from './gr-diff-host_html.js';
import {PatchSetBehavior} from '../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js';
import {GrDiffBuilder} from '../gr-diff-builder/gr-diff-builder.js';
import {parseDate} from '../../../utils/date-util.js';
import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
import {DiffSide, rangesEqual} from '../gr-diff/gr-diff-utils.js';
import {appContext} from '../../../services/app-context.js';

const MSG_EMPTY_BLAME = 'No blame information for this diff.';

const EVENT_AGAINST_PARENT = 'diff-against-parent';
const EVENT_ZERO_REBASE = 'rebase-percent-zero';
const EVENT_NONZERO_REBASE = 'rebase-percent-nonzero';

const DiffViewMode = {
  SIDE_BY_SIDE: 'SIDE_BY_SIDE',
  UNIFIED: 'UNIFIED_DIFF',
};

/** @enum {string} */
const TimingLabel = {
  TOTAL: 'Diff Total Render',
  CONTENT: 'Diff Content Render',
  SYNTAX: 'Diff Syntax Render',
};

// Disable syntax highlighting if the overall diff is too large.
const SYNTAX_MAX_DIFF_LENGTH = 20000;

// If any line of the diff is more than the character limit, then disable
// syntax highlighting for the entire file.
const SYNTAX_MAX_LINE_LENGTH = 500;

// 120 lines is good enough threshold for full-sized window viewport
const NUM_OF_LINES_THRESHOLD_FOR_VIEWPORT = 120;

const WHITESPACE_IGNORE_NONE = 'IGNORE_NONE';

/**
 * @param {Object} diff
 * @return {boolean}
 */
function isImageDiff(diff) {
  if (!diff) { return false; }

  const isA = diff.meta_a &&
      diff.meta_a.content_type.startsWith('image/');
  const isB = diff.meta_b &&
      diff.meta_b.content_type.startsWith('image/');

  return !!(diff.binary && (isA || isB));
}

/**
 * Wrapper around gr-diff.
 *
 * Webcomponent fetching diffs and related data from restAPI and passing them
 * to the presentational gr-diff for rendering.
 *
 * @extends PolymerElement
 */
class GrDiffHost extends mixinBehaviors( [
  PatchSetBehavior,
], GestureEventListeners(
    LegacyElementMixin(
        PolymerElement))) {
  static get template() { return htmlTemplate; }

  static get is() { return 'gr-diff-host'; }
  /**
   * Fired when the user selects a line.
   *
   * @event line-selected
   */

  /**
   * Fired if being logged in is required.
   *
   * @event show-auth-required
   */

  /**
   * Fired when a comment is saved or discarded
   *
   * @event diff-comments-modified
   */

  static get properties() {
    return {
      changeNum: String,
      noAutoRender: {
        type: Boolean,
        value: false,
      },
      /** @type {?} */
      patchRange: Object,
      /** @type {!Gerrit.FileRange} */
      file: Object,
      // TODO: deprecate path since that info is included in file
      path: String,
      prefs: {
        type: Object,
      },
      projectName: String,
      displayLine: {
        type: Boolean,
        value: false,
      },
      isImageDiff: {
        type: Boolean,
        computed: '_computeIsImageDiff(diff)',
        notify: true,
      },
      commitRange: Object,
      filesWeblinks: {
        type: Object,
        value() {
          return {};
        },
        notify: true,
      },
      hidden: {
        type: Boolean,
        reflectToAttribute: true,
      },
      noRenderOnPrefsChange: {
        type: Boolean,
        value: false,
      },
      comments: {
        type: Object,
        observer: '_commentsChanged',
      },
      lineWrapping: {
        type: Boolean,
        value: false,
      },
      viewMode: {
        type: String,
        value: DiffViewMode.SIDE_BY_SIDE,
      },

      /**
       * Special line number which should not be collapsed into a shared region.
       *
       * @type {{
       *  number: number,
       *  leftSide: {boolean}
       * }|null}
       */
      lineOfInterest: Object,

      /**
       * If the diff fails to load, show the failure message in the diff rather
       * than bubbling the error up to the whole page. This is useful for when
       * loading inline diffs because one diff failing need not mark the whole
       * page with a failure.
       */
      showLoadFailure: Boolean,

      isBlameLoaded: {
        type: Boolean,
        notify: true,
        computed: '_computeIsBlameLoaded(_blame)',
      },

      _loggedIn: {
        type: Boolean,
        value: false,
      },

      _loading: {
        type: Boolean,
        value: false,
      },

      /** @type {?string} */
      _errorMessage: {
        type: String,
        value: null,
      },

      /** @type {?Object} */
      _baseImage: Object,
      /** @type {?Object} */
      _revisionImage: Object,
      /**
       * This is a DiffInfo object.
       */
      diff: {
        type: Object,
        notify: true,
      },

      _fetchDiffPromise: {
        type: Object,
        value: null,
      },

      /** @type {?Object} */
      _blame: {
        type: Object,
        value: null,
      },

      /**
       * @type {!Array<!Gerrit.CoverageRange>}
       */
      _coverageRanges: {
        type: Array,
        value: () => [],
      },

      _loadedWhitespaceLevel: String,

      _parentIndex: {
        type: Number,
        computed: '_computeParentIndex(patchRange.*)',
      },

      _syntaxHighlightingEnabled: {
        type: Boolean,
        computed:
        '_isSyntaxHighlightingEnabled(prefs.*, diff)',
      },

      _layers: {
        type: Array,
        value: [],
      },
    };
  }

  static get observers() {
    return [
      '_whitespaceChanged(prefs.ignore_whitespace, _loadedWhitespaceLevel,' +
        ' noRenderOnPrefsChange)',
      '_syntaxHighlightingChanged(noRenderOnPrefsChange, prefs.*)',
    ];
  }

  constructor() {
    super();
    this.reporting = appContext.reportingService;
  }

  /** @override */
  created() {
    super.created();
    this.addEventListener(
        // These are named inconsistently for a reason:
        // The create-comment event is fired to indicate that we should
        // create a comment.
        // The comment-* events are just notifying that the comments did already
        // change in some way, and that we should update any models we may want
        // to keep in sync.
        'create-comment',
        e => this._handleCreateComment(e));
    this.addEventListener('comment-discard',
        e => this._handleCommentDiscard(e));
    this.addEventListener('comment-update',
        e => this._handleCommentUpdate(e));
    this.addEventListener('comment-save',
        e => this._handleCommentSave(e));
    this.addEventListener('render-start',
        () => this._handleRenderStart());
    this.addEventListener('render-content',
        () => this._handleRenderContent());
    this.addEventListener('normalize-range',
        event => this._handleNormalizeRange(event));
    this.addEventListener('diff-context-expanded',
        event => this._handleDiffContextExpanded(event));
  }

  /** @override */
  ready() {
    super.ready();
    if (this._canReload()) {
      this.reload();
    }
  }

  /** @override */
  attached() {
    super.attached();
    this._getLoggedIn().then(loggedIn => {
      this._loggedIn = loggedIn;
    });
  }

  /** @override */
  detached() {
    super.detached();
    this.clear();
  }

  /**
   * @param {boolean=} shouldReportMetric indicate a new Diff Page. This is a
   * signal to report metrics event that started on location change.
   * @return {!Promise}
   **/
  reload(shouldReportMetric) {
    this.clear();
    this._loading = true;
    this._errorMessage = null;
    const whitespaceLevel = this._getIgnoreWhitespace();

    const layers = [this.$.syntaxLayer];
    // Get layers from plugins (if any).
    for (const pluginLayer of this.$.jsAPI.getDiffLayers(
        this.path, this.changeNum, this.patchNum)) {
      layers.push(pluginLayer);
    }
    this._layers = layers;

    if (shouldReportMetric) {
      // We listen on render viewport only on DiffPage (on paramsChanged)
      this._listenToViewportRender();
    }

    this._coverageRanges = [];
    this._getCoverageData();
    const diffRequest = this._getDiff()
        .then(diff => {
          this._loadedWhitespaceLevel = whitespaceLevel;
          this._reportDiff(diff);
          return diff;
        })
        .catch(e => {
          this._handleGetDiffError(e);
          return null;
        });

    const assetRequest = diffRequest.then(diff => {
      // If the diff is null, then it's failed to load.
      if (!diff) { return null; }

      return this._loadDiffAssets(diff);
    });

    // Not waiting for coverage ranges intentionally as
    // plugin loading should not block the content rendering
    return Promise.all([diffRequest, assetRequest])
        .then(results => {
          const diff = results[0];
          if (!diff) {
            return Promise.resolve();
          }
          this.filesWeblinks = this._getFilesWeblinks(diff);
          return new Promise(resolve => {
            const callback = event => {
              const needsSyntaxHighlighting = event.detail &&
                    event.detail.contentRendered;
              if (needsSyntaxHighlighting) {
                this.reporting.time(TimingLabel.SYNTAX);
                this.$.syntaxLayer.process().finally(() => {
                  this.reporting.timeEnd(TimingLabel.SYNTAX);
                  this.reporting.timeEnd(TimingLabel.TOTAL);
                  resolve();
                });
              } else {
                this.reporting.timeEnd(TimingLabel.TOTAL);
                resolve();
              }
              this.removeEventListener('render', callback);
              if (shouldReportMetric) {
                // We report diffViewContentDisplayed only on reload caused
                // by params changed - expected only on Diff Page.
                this.reporting.diffViewContentDisplayed();
              }
            };
            this.addEventListener('render', callback);
            this.diff = diff;
          });
        })
        .catch(err => {
          console.warn('Error encountered loading diff:', err);
        })
        .then(() => { this._loading = false; });
  }

  clear() {
    this.$.jsAPI.disposeDiffLayers(this.path);
    this._layers = [];
  }

  _getCoverageData() {
    const {changeNum, path, patchRange: {basePatchNum, patchNum}} = this;
    this.$.jsAPI.getCoverageAnnotationApi().
        then(coverageAnnotationApi => {
          if (!coverageAnnotationApi) return;
          const provider = coverageAnnotationApi.getCoverageProvider();
          return provider(changeNum, path, basePatchNum, patchNum)
              .then(coverageRanges => {
                if (!coverageRanges ||
                  changeNum !== this.changeNum ||
                  path !== this.path ||
                  basePatchNum !== this.patchRange.basePatchNum ||
                  patchNum !== this.patchRange.patchNum) {
                  return;
                }

                const existingCoverageRanges = this._coverageRanges;
                this._coverageRanges = coverageRanges;

                // Notify with existing coverage ranges
                // in case there is some existing coverage data that needs to be removed
                existingCoverageRanges.forEach(range => {
                  coverageAnnotationApi.notify(
                      path,
                      range.code_range.start_line,
                      range.code_range.end_line,
                      range.side);
                });

                // Notify with new coverage data
                coverageRanges.forEach(range => {
                  coverageAnnotationApi.notify(
                      path,
                      range.code_range.start_line,
                      range.code_range.end_line,
                      range.side);
                });
              });
        })
        .catch(err => {
          console.warn('Loading coverage ranges failed: ', err);
        });
  }

  _getFilesWeblinks(diff) {
    if (!this.commitRange) {
      return {};
    }
    return {
      meta_a: GerritNav.getFileWebLinks(
          this.projectName, this.commitRange.baseCommit, this.path,
          {weblinks: diff && diff.meta_a && diff.meta_a.web_links}),
      meta_b: GerritNav.getFileWebLinks(
          this.projectName, this.commitRange.commit, this.path,
          {weblinks: diff && diff.meta_b && diff.meta_b.web_links}),
    };
  }

  /** Cancel any remaining diff builder rendering work. */
  cancel() {
    this.$.diff.cancel();
    this.$.syntaxLayer.cancel();
  }

  /** @return {!Array<!HTMLElement>} */
  getCursorStops() {
    return this.$.diff.getCursorStops();
  }

  /** @return {boolean} */
  isRangeSelected() {
    return this.$.diff.isRangeSelected();
  }

  createRangeComment() {
    return this.$.diff.createRangeComment();
  }

  toggleLeftDiff() {
    this.$.diff.toggleLeftDiff();
  }

  /**
   * Load and display blame information for the base of the diff.
   *
   * @return {Promise} A promise that resolves when blame finishes rendering.
   */
  loadBlame() {
    return this.$.restAPI.getBlame(this.changeNum, this.patchRange.patchNum,
        this.path, true)
        .then(blame => {
          if (!blame.length) {
            this.dispatchEvent(new CustomEvent('show-alert', {
              detail: {message: MSG_EMPTY_BLAME},
              composed: true, bubbles: true,
            }));
            return Promise.reject(MSG_EMPTY_BLAME);
          }

          this._blame = blame;
        });
  }

  /** Unload blame information for the diff. */
  clearBlame() {
    this._blame = null;
  }

  /**
   * The thread elements in this diff, in no particular order.
   *
   * @return {!Array<!HTMLElement>}
   */
  getThreadEls() {
    return Array.from(
        dom(this.$.diff).querySelectorAll('.comment-thread'));
  }

  /** @param {HTMLElement} el */
  addDraftAtLine(el) {
    this.$.diff.addDraftAtLine(el);
  }

  clearDiffContent() {
    this.$.diff.clearDiffContent();
  }

  expandAllContext() {
    this.$.diff.expandAllContext();
  }

  /** @return {!Promise} */
  _getLoggedIn() {
    return this.$.restAPI.getLoggedIn();
  }

  /** @return {boolean}} */
  _canReload() {
    return !!this.changeNum && !!this.patchRange && !!this.path &&
        !this.noAutoRender;
  }

  // TODO(milutin): Use rest-api with fetchCacheURL instead of this.
  prefetchDiff() {
    if (!!this.changeNum && !!this.patchRange && !!this.path
        && this._fetchDiffPromise === null) {
      this._fetchDiffPromise = this._getDiff();
    }
  }

  /** @return {!Promise<!Object>} */
  _getDiff() {
    if (this._fetchDiffPromise !== null) {
      const fetchDiffPromise = this._fetchDiffPromise;
      this._fetchDiffPromise = null;
      return fetchDiffPromise;
    }
    // Wrap the diff request in a new promise so that the error handler
    // rejects the promise, allowing the error to be handled in the .catch.
    return new Promise((resolve, reject) => {
      this.$.restAPI.getDiff(
          this.changeNum,
          this.patchRange.basePatchNum,
          this.patchRange.patchNum,
          this.path,
          this._getIgnoreWhitespace(),
          reject)
          .then(resolve);
    });
  }

  _handleGetDiffError(response) {
    // Loading the diff may respond with 409 if the file is too large. In this
    // case, use a toast error..
    if (response.status === 409) {
      this.dispatchEvent(new CustomEvent('server-error', {
        detail: {response},
        composed: true, bubbles: true,
      }));
      return;
    }

    if (this.showLoadFailure) {
      this._errorMessage = [
        'Encountered error when loading the diff:',
        response.status,
        response.statusText,
      ].join(' ');
      return;
    }

    this.dispatchEvent(new CustomEvent('page-error', {
      detail: {response},
      composed: true, bubbles: true,
    }));
  }

  /**
   * Report info about the diff response.
   */
  _reportDiff(diff) {
    if (!diff || !diff.content) {
      return;
    }

    // Count the delta lines stemming from normal deltas, and from
    // due_to_rebase deltas.
    let nonRebaseDelta = 0;
    let rebaseDelta = 0;
    diff.content.forEach(chunk => {
      if (chunk.ab) { return; }
      const deltaSize = Math.max(
          chunk.a ? chunk.a.length : 0, chunk.b ? chunk.b.length : 0);
      if (chunk.due_to_rebase) {
        rebaseDelta += deltaSize;
      } else {
        nonRebaseDelta += deltaSize;
      }
    });

    // Find the percent of the delta from due_to_rebase chunks rounded to two
    // digits. Diffs with no delta are considered 0%.
    const totalDelta = rebaseDelta + nonRebaseDelta;
    const percentRebaseDelta = !totalDelta ? 0 :
      Math.round(100 * rebaseDelta / totalDelta);

    // Report the due_to_rebase percentage in the "diff" category when
    // applicable.
    if (this.patchRange.basePatchNum === 'PARENT') {
      this.reporting.reportInteraction(EVENT_AGAINST_PARENT);
    } else if (percentRebaseDelta === 0) {
      this.reporting.reportInteraction(EVENT_ZERO_REBASE);
    } else {
      this.reporting.reportInteraction(EVENT_NONZERO_REBASE,
          {percentRebaseDelta});
    }
  }

  /**
   * @param {Object} diff
   * @return {!Promise}
   */
  _loadDiffAssets(diff) {
    if (isImageDiff(diff)) {
      return this._getImages(diff).then(images => {
        this._baseImage = images.baseImage;
        this._revisionImage = images.revisionImage;
      });
    } else {
      this._baseImage = null;
      this._revisionImage = null;
      return Promise.resolve();
    }
  }

  /**
   * @param {Object} diff
   * @return {boolean}
   */
  _computeIsImageDiff(diff) {
    return isImageDiff(diff);
  }

  _commentsChanged(newComments) {
    const allComments = [];
    for (const side of [GrDiffBuilder.Side.LEFT, GrDiffBuilder.Side.RIGHT]) {
      // This is needed by the threading.
      for (const comment of newComments[side]) {
        comment.__commentSide = side;
      }
      allComments.push(...newComments[side]);
    }
    // Currently, the only way this is ever changed here is when the initial
    // comments are loaded, so it's okay performance wise to clear the threads
    // and recreate them. If this changes in future, we might want to reuse
    // some DOM nodes here.
    this._clearThreads();
    const threads = this._createThreads(allComments);
    for (const thread of threads) {
      const threadEl = this._createThreadElement(thread);
      this._attachThreadElement(threadEl);
    }
  }

  _sortComments(comments) {
    return comments.slice(0).sort((a, b) => {
      if (b.__draft && !a.__draft ) { return -1; }
      if (a.__draft && !b.__draft ) { return 1; }
      return parseDate(a.updated) - parseDate(b.updated);
    });
  }

  /**
   * @param {!Array<!Object>} comments
   * @return {!Array<!Object>} Threads for the given comments.
   */
  _createThreads(comments) {
    const sortedComments = this._sortComments(comments);
    const threads = [];
    for (const comment of sortedComments) {
      // If the comment is in reply to another comment, find that comment's
      // thread and append to it.
      if (comment.in_reply_to) {
        const thread = threads.find(thread =>
          thread.comments.some(c => c.id === comment.in_reply_to));
        if (thread) {
          thread.comments.push(comment);
          continue;
        }
      }

      // Otherwise, this comment starts its own thread.
      const newThread = {
        start_datetime: comment.updated,
        comments: [comment],
        commentSide: comment.__commentSide,
        patchNum: comment.patch_set,
        rootId: comment.id || comment.__draftID,
        lineNum: comment.line,
        isOnParent: comment.side === 'PARENT',
      };
      if (comment.range) {
        newThread.range = Object.assign({}, comment.range);
      }
      threads.push(newThread);
    }
    return threads;
  }

  /**
   * @param {Object} blame
   * @return {boolean}
   */
  _computeIsBlameLoaded(blame) {
    return !!blame;
  }

  /**
   * @param {Object} diff
   * @return {!Promise}
   */
  _getImages(diff) {
    return this.$.restAPI.getImagesForDiff(this.changeNum, diff,
        this.patchRange);
  }

  /** @param {CustomEvent} e */
  _handleCreateComment(e) {
    const {lineNum, side, patchNum, isOnParent, range} = e.detail;
    const threadEl = this._getOrCreateThread(patchNum, lineNum, side, range,
        isOnParent);
    threadEl.addOrEditDraft(lineNum, range);

    this.reporting.recordDraftInteraction();
  }

  /**
   * Gets or creates a comment thread at a given location.
   * May provide a range, to get/create a range comment.
   *
   * @param {string} patchNum
   * @param {?number} lineNum
   * @param {string} commentSide
   * @param {Gerrit.Range|undefined} range
   * @param {boolean} isOnParent
   * @return {!Object}
   */
  _getOrCreateThread(patchNum, lineNum, commentSide, range, isOnParent) {
    let threadEl = this._getThreadEl(lineNum, commentSide, range);
    if (!threadEl) {
      threadEl = this._createThreadElement({
        comments: [],
        commentSide,
        patchNum,
        lineNum,
        range,
        isOnParent,
      });
      this._attachThreadElement(threadEl);
    }
    return threadEl;
  }

  _attachThreadElement(threadEl) {
    dom(this.$.diff).appendChild(threadEl);
  }

  _clearThreads() {
    for (const threadEl of this.getThreadEls()) {
      const parent = dom(threadEl).parentNode;
      dom(parent).removeChild(threadEl);
    }
  }

  _createThreadElement(thread) {
    const threadEl = document.createElement('gr-comment-thread');
    threadEl.className = 'comment-thread';
    threadEl.setAttribute('slot', `${thread.commentSide}-${thread.lineNum}`);
    threadEl.comments = thread.comments;
    threadEl.commentSide = thread.commentSide;
    threadEl.isOnParent = !!thread.isOnParent;
    threadEl.parentIndex = this._parentIndex;
    // Use path before renmaing when comment added on the left when comparing
    // two patch sets (not against base)
    if (this.file && this.file.basePath
        && thread.commentSide === GrDiffBuilder.Side.LEFT
        && !thread.isOnParent) {
      threadEl.path = this.file.basePath;
    } else {
      threadEl.path = this.path;
    }
    threadEl.changeNum = this.changeNum;
    threadEl.patchNum = thread.patchNum;
    threadEl.showPatchset = false;
    threadEl.lineNum = thread.lineNum;
    const rootIdChangedListener = changeEvent => {
      thread.rootId = changeEvent.detail.value;
    };
    threadEl.addEventListener('root-id-changed', rootIdChangedListener);
    threadEl.projectName = this.projectName;
    threadEl.range = thread.range;
    const threadDiscardListener = e => {
      const threadEl = /** @type {!Node} */ (e.currentTarget);

      const parent = dom(threadEl).parentNode;
      dom(parent).removeChild(threadEl);

      threadEl.removeEventListener('root-id-changed', rootIdChangedListener);
      threadEl.removeEventListener('thread-discard', threadDiscardListener);
    };
    threadEl.addEventListener('thread-discard', threadDiscardListener);
    return threadEl;
  }

  /**
   * Gets a comment thread element at a given location.
   * May provide a range, to get a range comment.
   *
   * @param {?number} lineNum
   * @param {string} commentSide
   * @param {!Gerrit.Range=} range
   * @return {?Node}
   */
  _getThreadEl(lineNum, commentSide, range = undefined) {
    let line;
    if (commentSide === GrDiffBuilder.Side.LEFT) {
      line = {beforeNumber: lineNum};
    } else if (commentSide === GrDiffBuilder.Side.RIGHT) {
      line = {afterNumber: lineNum};
    } else {
      throw new Error(`Unknown side: ${commentSide}`);
    }
    function matchesRange(threadEl) {
      const threadRange = /** @type {!Gerrit.Range} */(
        JSON.parse(threadEl.getAttribute('range')));
      return rangesEqual(threadRange, range);
    }

    const filteredThreadEls = this._filterThreadElsForLocation(
        this.getThreadEls(), line, commentSide).filter(matchesRange);
    return filteredThreadEls.length ? filteredThreadEls[0] : null;
  }

  /**
   * @param {!Array<!HTMLElement>} threadEls
   * @param {!{beforeNumber: (number|string|undefined|null),
   *           afterNumber: (number|string|undefined|null)}}
   *     lineInfo
   * @param {!DiffSide=} side The side (LEFT, RIGHT) for
   *     which to return the threads.
   * @return {!Array<!HTMLElement>} The thread elements matching the given
   *     location.
   */
  _filterThreadElsForLocation(threadEls, lineInfo, side) {
    function matchesLeftLine(threadEl) {
      return threadEl.getAttribute('comment-side') ==
          DiffSide.LEFT &&
          threadEl.getAttribute('line-num') == lineInfo.beforeNumber;
    }
    function matchesRightLine(threadEl) {
      return threadEl.getAttribute('comment-side') ==
          DiffSide.RIGHT &&
          threadEl.getAttribute('line-num') == lineInfo.afterNumber;
    }
    function matchesFileComment(threadEl) {
      return threadEl.getAttribute('comment-side') == side &&
            // line/range comments have 1-based line set, if line is falsy it's
            // a file comment
            !threadEl.getAttribute('line-num');
    }

    // Select the appropriate matchers for the desired side and line
    // If side is BOTH, we want both the left and right matcher.
    const matchers = [];
    if (side !== DiffSide.RIGHT) {
      matchers.push(matchesLeftLine);
    }
    if (side !== DiffSide.LEFT) {
      matchers.push(matchesRightLine);
    }
    if (lineInfo.afterNumber === 'FILE' ||
        lineInfo.beforeNumber === 'FILE') {
      matchers.push(matchesFileComment);
    }
    return threadEls.filter(threadEl =>
      matchers.some(matcher => matcher(threadEl)));
  }

  _getIgnoreWhitespace() {
    if (!this.prefs || !this.prefs.ignore_whitespace) {
      return WHITESPACE_IGNORE_NONE;
    }
    return this.prefs.ignore_whitespace;
  }

  _whitespaceChanged(
      preferredWhitespaceLevel, loadedWhitespaceLevel,
      noRenderOnPrefsChange) {
    // Polymer 2: check for undefined
    if ([
      preferredWhitespaceLevel,
      loadedWhitespaceLevel,
      noRenderOnPrefsChange,
    ].includes(undefined)) {
      return;
    }

    this._fetchDiffPromise = null;
    if (preferredWhitespaceLevel !== loadedWhitespaceLevel &&
        !noRenderOnPrefsChange) {
      this.reload();
    }
  }

  _syntaxHighlightingChanged(noRenderOnPrefsChange, prefsChangeRecord) {
    // Polymer 2: check for undefined
    if ([
      noRenderOnPrefsChange,
      prefsChangeRecord,
    ].includes(undefined)) {
      return;
    }

    if (prefsChangeRecord.path !== 'prefs.syntax_highlighting') {
      return;
    }

    if (!noRenderOnPrefsChange) {
      this.reload();
    }
  }

  /**
   * @param {Object} patchRangeRecord
   * @return {number|null}
   */
  _computeParentIndex(patchRangeRecord) {
    return this.isMergeParent(patchRangeRecord.base.basePatchNum) ?
      this.getParentIndex(patchRangeRecord.base.basePatchNum) : null;
  }

  _handleCommentSave(e) {
    const comment = e.detail.comment;
    const side = e.detail.comment.__commentSide;
    const idx = this._findDraftIndex(comment, side);
    this.set(['comments', side, idx], comment);
    this._handleCommentSaveOrDiscard();
  }

  _handleCommentDiscard(e) {
    const comment = e.detail.comment;
    this._removeComment(comment);
    this._handleCommentSaveOrDiscard();
  }

  /**
   * Closure annotation for Polymer.prototype.push is off. Submitted PR:
   * https://github.com/Polymer/polymer/pull/4776
   * but for not suppressing annotations.
   *
   * @suppress {checkTypes}
   */
  _handleCommentUpdate(e) {
    const comment = e.detail.comment;
    const side = e.detail.comment.__commentSide;
    let idx = this._findCommentIndex(comment, side);
    if (idx === -1) {
      idx = this._findDraftIndex(comment, side);
    }
    if (idx !== -1) { // Update draft or comment.
      this.set(['comments', side, idx], comment);
    } else { // Create new draft.
      this.push(['comments', side], comment);
    }
  }

  _handleCommentSaveOrDiscard() {
    this.dispatchEvent(new CustomEvent(
        'diff-comments-modified', {bubbles: true, composed: true}));
  }

  _removeComment(comment) {
    const side = comment.__commentSide;
    this._removeCommentFromSide(comment, side);
  }

  _removeCommentFromSide(comment, side) {
    let idx = this._findCommentIndex(comment, side);
    if (idx === -1) {
      idx = this._findDraftIndex(comment, side);
    }
    if (idx !== -1) {
      this.splice('comments.' + side, idx, 1);
    }
  }

  /** @return {number} */
  _findCommentIndex(comment, side) {
    if (!comment.id || !this.comments[side]) {
      return -1;
    }
    return this.comments[side].findIndex(item => item.id === comment.id);
  }

  /** @return {number} */
  _findDraftIndex(comment, side) {
    if (!comment.__draftID || !this.comments[side]) {
      return -1;
    }
    return this.comments[side].findIndex(
        item => item.__draftID === comment.__draftID);
  }

  _isSyntaxHighlightingEnabled(preferenceChangeRecord, diff) {
    if (!preferenceChangeRecord ||
        !preferenceChangeRecord.base ||
        !preferenceChangeRecord.base.syntax_highlighting ||
        !diff) {
      return false;
    }
    return !this._anyLineTooLong(diff) &&
        this.$.diff.getDiffLength(diff) <= SYNTAX_MAX_DIFF_LENGTH;
  }

  /**
   * @return {boolean} whether any of the lines in diff are longer
   * than SYNTAX_MAX_LINE_LENGTH.
   */
  _anyLineTooLong(diff) {
    if (!diff) return false;
    return diff.content.some(section => {
      const lines = section.ab ?
        section.ab :
        (section.a || []).concat(section.b || []);
      return lines.some(line => line.length >= SYNTAX_MAX_LINE_LENGTH);
    });
  }

  _listenToViewportRender() {
    const renderUpdateListener = start => {
      if (start > NUM_OF_LINES_THRESHOLD_FOR_VIEWPORT) {
        this.reporting.diffViewDisplayed();
        this.$.syntaxLayer.removeListener(renderUpdateListener);
      }
    };

    this.$.syntaxLayer.addListener(renderUpdateListener);
  }

  _handleRenderStart() {
    this.reporting.time(TimingLabel.TOTAL);
    this.reporting.time(TimingLabel.CONTENT);
  }

  _handleRenderContent() {
    this.reporting.timeEnd(TimingLabel.CONTENT);
  }

  _handleNormalizeRange(event) {
    this.reporting.reportInteraction('normalize-range',
        {
          side: event.detail.side,
          lineNum: event.detail.lineNum,
        });
  }

  _handleDiffContextExpanded(event) {
    this.reporting.reportInteraction(
        'diff-context-expanded', {numLines: event.detail.numLines}
    );
  }

  /**
   * Find the last chunk for the given side.
   *
   * @param {!Object} diff
   * @param {boolean} leftSide true if checking the base of the diff,
   *     false if testing the revision.
   * @return {Object|null} returns the chunk object or null if there was
   *     no chunk for that side.
   */
  _lastChunkForSide(diff, leftSide) {
    if (!diff.content.length) { return null; }

    let chunkIndex = diff.content.length;
    let chunk;

    // Walk backwards until we find a chunk for the given side.
    do {
      chunkIndex--;
      chunk = diff.content[chunkIndex];
    } while (
    // We haven't reached the beginning.
      chunkIndex >= 0 &&

        // The chunk doesn't have both sides.
        !chunk.ab &&

        // The chunk doesn't have the given side.
        ((leftSide && (!chunk.a || !chunk.a.length)) ||
         (!leftSide && (!chunk.b || !chunk.b.length))));

    // If we reached the beginning of the diff and failed to find a chunk
    // with the given side, return null.
    if (chunkIndex === -1) { return null; }

    return chunk;
  }

  /**
   * Check whether the specified side of the diff has a trailing newline.
   *
   * @param {!Object} diff
   * @param {boolean} leftSide true if checking the base of the diff,
   *     false if testing the revision.
   * @return {boolean|null} Return true if the side has a trailing newline.
   *     Return false if it doesn't. Return null if not applicable (for
   *     example, if the diff has no content on the specified side).
   */
  _hasTrailingNewlines(diff, leftSide) {
    const chunk = this._lastChunkForSide(diff, leftSide);
    if (!chunk) { return null; }
    let lines;
    if (chunk.ab) {
      lines = chunk.ab;
    } else {
      lines = leftSide ? chunk.a : chunk.b;
    }
    return lines[lines.length - 1] === '';
  }

  _showNewlineWarningLeft(diff) {
    return this._hasTrailingNewlines(diff, true) === false;
  }

  _showNewlineWarningRight(diff) {
    return this._hasTrailingNewlines(diff, false) === false;
  }
}

customElements.define(GrDiffHost.is, GrDiffHost);
